iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0
生成式 AI

AI LeetCode 助教:30 天打造智慧刷題系統系列 第 3

Day 3:新增程度測驗流程

  • 分享至 

  • xImage
  •  

今日目標

  • 後端新增 Assessment 路由:
    • GET /assessment/start?user_id=:挑出 3 題(Easy/Medium/Hard 各一)
    • POST /assessment/score:回傳等級與細節,並寫回 users.level
  • 制定簡單計分規則
  • 前端新增開始程度測驗頁面(列題 → 回報結果 → 顯示建議等級)

簡單計分規則

  • Easy/Medium/Hard 權重:1/2/3
  • verdict 加權:accepted=1.0、wrong answer=0.2、Time limit error=0.5、skipped=0.0
  • 使用提示(hint_used=true)每題 -0.2(下限 0)
  • 總分 0~6,等級:
    • 0 ~ 1.9 → beginner
    • 2.0 ~ 4.0 → intermediate
    • 4.0 up → advanced

這些只是目前測試用的簡單版本,後續會考慮到提示次數、解題時間等,都會列入計分。

實作步驟

1. 後端:DTO 擴充

保留前兩天backend/dto.py的內容,加入以下程式碼

# backend/dto.py
from typing import List

class AssessmentProblem(BaseModel):
    id: int
    slug: str
    title: str
    difficulty: str
    topic: str
    model_config = ConfigDict(from_attributes=True)

class AssessmentStartOut(BaseModel):
    user_id: int
    problems: List[AssessmentProblem]

class AssessmentItemIn(BaseModel):
    problem_id: int
    verdict: Literal["accepted", "wrong", "tle", "skipped"] = "skipped"
    hint_used: bool = False

class AssessmentScoreIn(BaseModel):
    user_id: int
    items: List[AssessmentItemIn]

class AssessmentResultOut(BaseModel):
    user_id: int
    level: Literal["beginner", "intermediate", "advanced"]
    score: float
    breakdown: dict

2. 後端:Assessment 路由

  • GET /assessment/start:為指定使用者抽出 3 題(Easy/Medium/Hard 各一;若該難度無題則少於 3)。
  • POST /assessment/score:根據回報結果計分與分級,並更新 users.level。
# backend/routers/assessment.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import SessionLocal
from .. import models
from ..dto import (
    AssessmentStartOut, AssessmentProblem,
    AssessmentScoreIn, AssessmentResultOut
)

router = APIRouter(prefix="/assessment", tags=["assessment"])

def get_db():
    db = SessionLocal()
    try: yield db
    finally: db.close()

def pick_one(db: Session, difficulty: str):
    # SQLite 的隨機排序
    return (db.query(models.Problem)
              .filter(models.Problem.is_active == True,
                      models.Problem.difficulty == difficulty)
              .order_by(func.random()).first())

@router.get("/start", response_model=AssessmentStartOut)
def start_assessment(user_id: int, db: Session = Depends(get_db)):
    user = db.query(models.User).filter(models.User.id == user_id,
                                        models.User.is_active == True).first()
    if not user:
        raise HTTPException(404, "user not found")

    e = pick_one(db, "Easy")
    m = pick_one(db, "Medium")
    h = pick_one(db, "Hard")
    problems = [p for p in [e, m, h] if p is not None]
    if not problems:
        raise HTTPException(400, "no problems available for assessment")

    out = AssessmentStartOut(
        user_id=user.id,
        problems=[AssessmentProblem.model_validate(p) for p in problems]
    )
    return out

@router.post("/score", response_model=AssessmentResultOut)
def score_assessment(payload: AssessmentScoreIn, db: Session = Depends(get_db)):
    user = db.query(models.User).filter(models.User.id == payload.user_id).first()
    if not user:
        raise HTTPException(404, "user not found")

    # 權重設定
    diff_w = {"Easy": 1.0, "Medium": 2.0, "Hard": 3.0}
    verdict_w = {"accepted": 1.0, "wrong": 0.2, "tle": 0.5, "skipped": 0.0}

    total = 0.0
    breakdown = {}
    for it in payload.items:
        p = db.query(models.Problem).get(it.problem_id)
        if not p:
            continue
        base = diff_w.get(p.difficulty, 1.0) * verdict_w.get(it.verdict, 0.0)
        if it.hint_used:
            base = max(0.0, base - 0.2)
        total += base
        breakdown[str(p.id)] = {
            "title": p.title,
            "difficulty": p.difficulty,
            "verdict": it.verdict,
            "hint_used": it.hint_used,
            "score": round(base, 2),
        }

    # 0~6 區間(最多 1+2+3=6)
    score = round(total, 2)
    if score > 4.0:
        level = "advanced"
    elif score >= 2.0:
        level = "intermediate"
    else:
        level = "beginner"

    # 寫回使用者等級
    user.level = level
    db.add(user); db.commit()

    return AssessmentResultOut(
        user_id=user.id,
        level=level,
        score=score,
        breakdown=breakdown
    )

3. 後端:掛上新路由

把 assessment 路由註冊進 FastAPI,讓 assessment 可被存取。
在 app.py 裡 import 並 include

from .routers import assessment
...(原本的程式)
app.include_router(assessment.router)

做完以上步驟,就可以重啟後端:

python -m uvicorn backend.app:app --reload

Swagger 檢查:
GET /assessment/start?user_id=1 → 應回三題
POST /assessment/score:

{
  "user_id": 1, //編號記得改
  "items": [
    {"problem_id": 1, "verdict": "accepted", "hint_used": false},
    {"problem_id": 2, "verdict": "wrong", "hint_used": true},
    {"problem_id": 3, "verdict": "skipped", "hint_used": false}
  ]
}

→ 應回 level, score, breakdown 並更新 users.level

4. 前端:新增程度測驗頁面

在現有的前端後面,加入一個三題快速分級的功能:
抽題 → 顯示題目 → 在畫面選擇 verdict/hint_used → 一鍵送出 → 顯示等級與每題計分。
將程式接續到frontend/app.py後面:

# frontend/app.py
st.header("程度測驗(3 題快速分級)")

if "assessment_user_id" not in st.session_state:
    st.session_state.assessment_user_id = 1   # 先固定 1,之後做登入再帶入
if "assessment_problems" not in st.session_state:
    st.session_state.assessment_problems = None
if "assessment_results" not in st.session_state:
    st.session_state.assessment_results = {}

col_a, col_b = st.columns([1, 3])
with col_a:
    if st.button("開始測驗 / 重新抽題"):
        try:
            resp = requests.get(
                f"{API_BASE}/assessment/start",
                params={"user_id": st.session_state.assessment_user_id},
                timeout=8
            )
            st.session_state.assessment_problems = resp.json()["problems"]
            st.session_state.assessment_results = {}  # reset
            st.success("已抽出 3 題,請回報結果")
        except Exception as e:
            st.error(f"抽題失敗:{e}")

if st.session_state.assessment_problems:
    st.write("請到 LeetCode 作答,或根據理解回報本題結果:")
    for p in st.session_state.assessment_problems:
        st.markdown(
            f"- **[{p['difficulty']}] {p['title']}** / topic: {p['topic']} / "
            f"slug: `{p['slug']}`"
        )
        verdict = st.selectbox(
            f"結果(題目 #{p['id']})",
            ["accepted", "wrong", "tle", "skipped"],
            key=f"verdict_{p['id']}"
        )
        hint_used = st.checkbox(
            f"該題有使用提示(題目 #{p['id']})",
            key=f"hint_{p['id']}"
        )
        st.session_state.assessment_results[p["id"]] = {
            "problem_id": p["id"], "verdict": verdict, "hint_used": hint_used
        }

    if st.button("送出並計分"):
        try:
            payload = {
                "user_id": st.session_state.assessment_user_id,
                "items": list(st.session_state.assessment_results.values())
            }
            resp = requests.post(f"{API_BASE}/assessment/score", json=payload, timeout=10)
            data = resp.json()
            st.success(f"建議等級:**{data['level']}**(score={data['score']})")
            with st.expander("查看每題計分"):
                for pid, info in data["breakdown"].items():
                    st.write(f"- #{pid} {info['title']} / {info['difficulty']} → "
                             f"{info['verdict']} / hint={info['hint_used']} / score={info['score']}")
        except Exception as e:
            st.error(f"計分失敗:{e}")

今日成果

隨機抽出三題,一題Easy,一題Medinum,一題Hard:
https://ithelp.ithome.com.tw/upload/images/20250817/20146177NY32AmRfPd.png

做完後可選擇解題狀態(Accepted、WA、TLE...)
https://ithelp.ithome.com.tw/upload/images/20250817/20146177eLwfB5UwLz.png

送出後可查看等級與每題計分。
https://ithelp.ithome.com.tw/upload/images/20250817/20146177NzDLh9wEEI.png


上一篇
Day 2:資料模型擴充 + 篩選查詢 + 基礎 CRUD
下一篇
Day 4:提示系統功能整合
系列文
AI LeetCode 助教:30 天打造智慧刷題系統4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言